feat(strands): bridge native Strands interrupts to AG-UI interrupt round-trip - #2286
feat(strands): bridge native Strands interrupts to AG-UI interrupt round-trip#2286ciolo wants to merge 8 commits into
Conversation
…und-trip
Translate native Strands `tool_context.interrupt()` pauses into the AG-UI
interrupt lifecycle so human-in-the-loop flows work over the protocol:
- Consume `RunAgentInput.resume`, building the Strands resume prompt
`[{"interruptResponse": {"interruptId", "response"}}]`; `status="cancelled"`
resumes with the `INTERRUPT_CANCELLED` denial sentinel.
- Detect a paused run after the stream loop via the terminal `AgentResult`
(`stop_reason == "interrupt"`), falling back to the agent's
`_interrupt_state` when the result event is consumed by the early-break path.
- Emit `RunFinishedInterruptOutcome` with one AG-UI `Interrupt` per Strands
interrupt (name -> categorical `reason`, original reason under
`metadata.strands_reason`); non-interrupt runs still finish bare.
- Bump `ag-ui-protocol` floor to >=0.1.19 (ships the interrupt types).
- Document the HITL round-trip and the durable `SessionManager` requirement
for stateless deployments; add unit tests for all four behaviors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
269a418 to
062cb45
Compare
|
hi @contextablemark and @ranst91 ! Apologies for direct tagging. |
Python Preview PackagesVersion
Install with uvAdd the TestPyPI index to your [[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = trueThen install the packages you need: # Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1785893517' --index testpypi
# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1785893517' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1785893517' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1785893517' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1785893517' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1785893517' --index testpypiInstall with pippip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
ag-ui-protocol==0.0.0.dev1785893517
Commit: 7b4c8f2 |
@ag-ui/a2a-middleware
@ag-ui/a2ui-middleware
@ag-ui/event-throttle-middleware
@ag-ui/mcp-apps-middleware
@ag-ui/mcp-middleware
@ag-ui/a2a
@ag-ui/adk
@ag-ui/ag2
@ag-ui/agno
@ag-ui/aws-strands
@ag-ui/claude-agent-sdk
@ag-ui/claude-managed-agents
@ag-ui/crewai
@ag-ui/langchain
@ag-ui/langgraph
@ag-ui/llamaindex
@ag-ui/mastra
@ag-ui/pydantic-ai
@ag-ui/vercel-ai-sdk
@ag-ui/watsonx
@ag-ui/a2ui-toolkit
create-ag-ui-app
@ag-ui/client
@ag-ui/core
@ag-ui/encoder
@ag-ui/proto
commit: |
|
Thanks for this, and apologies for the slow review — worth explaining why, because you've walked into a genuinely crowded area rather than a quiet one. Context: four changes are currently converging on
We've verified yours is orthogonal to the halt work: cherry-picking our commits onto your head applies with zero conflicts, the union suite is green, and your feature behaves identically with and without them. So this isn't blocked on the others — but it does explain the delay, and it's why the review below is unusually specific about which code path each finding lives on. Separately, the Now the review. Credit where it's due first: this avoids three traps that are genuinely hard to know about, and we only found them by instrumenting Strands directly.
The Three blocking issues1. A falsy resume payload never resolves. This is the default path, not an edge case: 2. The resume branch should be additive, not exclusive. The comment at 3. Non-blocking4. Populate 5. Add an escape from an abandoned interrupt. A next turn with no 6. One test that drives a real 7. Three small ones. (a) Move the 8. Two things for the README: the interrupted tool's body re-executes on resume (measured), so anything before Happy to pair on 1–3 if useful, and the dojo cc @leotac, since you asked about adoption timing. |
Co-authored-by: Francesco De Felice <francesco.de.felice@verizonconnect.com>
|
Hi @contextablemark, thank you for reviewing our contribution and the valuable feedback. As we work through them, especially the second one (The resume branch should be additive, not exclusive.) we noticed that the whole |
* docs(tools): document ToolMessage.error in tool result examples `ToolMessage.error` is documented on the messages concept page and in the JS, Python, and Ruby SDK references, but two pages omit it -- both pages an integration author is likely to work from. - concepts/tools.mdx: the Tool Results example showed only `content` + `toolCallId`. Adds a failure example alongside the existing success one, reusing the page's confirmAction / tool-123 scenario. - sdk/kotlin/core/types.mdx: the ToolMessage declaration omitted `error`, which the Kotlin SDK does have. Also corrects the name / toolCallId order to match the source. concepts/events.mdx was checked and left alone: ToolCallResultEvent has no `error` field in either SDK, so its property table is accurate. Refs ag-ui-protocol#2306, ag-ui-protocol#2226 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(python): fail the build on a stale uv.lock Nothing validated Python lockfiles. Every --frozen-lockfile check in CI is pnpm, which is how three released packages drifted -- ag_ui_adk five releases deep -- without a single red build (ag-ui-protocol#2313 repaired it, ag-ui-protocol#2314 removes the cause). Adds --locked to the nine `uv sync` steps rather than nine new `uv lock --check` steps: uv sync --locked asserts the lockfile is already in step with pyproject.toml and exits 1 if not, so the gate rides along with a command CI already runs. Measured on the aws-strands package -- with the lock's self-version knocked back a patch it exits 1 with "The lockfile at `uv.lock` needs to be updated, but `--locked` was provided", and exits 0 once restored. Safe to merge as-is: verified that all eight uv-managed packages this workflow covers currently pass `uv lock --check` against main, so no job goes red on landing. Note the check is resolution-level, not byte-level -- it tolerates a lock whose metadata representation is merely older than what today's uv would emit, and fails only when the lock genuinely disagrees with pyproject. That is the right sensitivity here: the version drift this exists to catch trips it, and a uv upgrade alone does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(release): re-lock uv-managed Python packages after a version bump uv.lock carries an entry for the package it locks -- the one whose source is { editable = "." } -- so editing pyproject.toml alone leaves that entry one version stale. prepare-release.ts did exactly that, so every release shipped a stale lock: four consecutive aws-strands releases are each a one-line, one-file commit, and ag_ui_adk drifted five releases deep before anyone noticed. ag-ui-protocol#2313 repaired the accumulated drift; this stops it recurring. Runs `uv lock`, which is what the tooling is for. Note it may also flush latent metadata corrections unrelated to the bump: it rewrites the whole file once it has any reason to, and what it writes reflects the package metadata in uv's cache at that moment. Measured -- the same uv 0.9.24 binary added an exceptiongroup dependency marker on Aug 4 that it had not added on Jul 30 from an identical starting state, because the cache had refreshed from PyPI in between. Those are corrections rather than corruption (exceptiongroup genuinely needs typing-extensions only below 3.13), and the companion `uv lock --check` CI gate (ag-ui-protocol#2315) keeps them from accumulating: with locks continuously current, a release bump has nothing extra to flush and its diff stays to the version line. Packages with no uv.lock (poetry-managed, or unlocked) are skipped; a missing uv is fatal rather than skipped, since silently publishing a stale lock is the failure this exists to prevent. uv's stdout is discarded so the script's JSON summary stays parseable, with stderr passed through for diagnostics. Testability: repoRoot was pinned to the script's own location, so a non-dry-run would have edited the real repo and --dry-run (which never writes) was the only safe mode -- leaving the write path uncovered. It now honours PREPARE_RELEASE_ROOT, so a test can point config, package files and lockfiles at a throwaway tree. The added test builds a dependency-free fixture package (so `uv lock` needs no network), seeds its lock with a real uv run rather than hand-writing one, bumps it through the actual script, and asserts pyproject.toml and uv.lock both land on the new version. Commenting out the re-lock makes it fail with "uv.lock self-entry not re-locked", so it guards the regression rather than the implementation. The test skips itself when uv is absent, so test-release-scripts.yml gains the uv install -- otherwise it would pass vacuously in CI and give false confidence. Rejected: a main-guard refactor to make the script importable. A guard whose condition silently mismatched would stop main() running at all -- a quiet no-op in release tooling, worse than the bug being fixed. The env override achieves the same testability with no effect on normal invocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): update github/codeql-action action to v4.37.6 * fix(repo): reconcile Git LFS policy with repository contents `git lfs fsck --pointers HEAD` reported 34 files committed as ordinary Git blobs on paths that .gitattributes declares LFS-governed, and every clone printed a 34-line "should have been pointers, but weren't" notice. PNI-185 proposed converting those 34 files to pointers. Investigating each one showed the policy, not the contents, is what is wrong: * docs/** (9 files, 7.4 MB) is served straight from a repository checkout by Mintlify, which does not hydrate Git LFS -- an open, unanswered feature request since March 2026. Converting these would serve 130-byte pointer text in place of every docs image and the landing-page video. * apps/dojo/public|src (14 files, 3.1 MB) is served the same way by Render. * The Kotlin example's launcher icons (10 files) are Android Studio template output at 1-8 KB each -- smaller than the overhead LFS adds. * config_test.dill (10.3 MB, 48% of the total) is a Dart kernel snapshot: compiler output committed by accident in ag-ui-protocol#316, referenced by nothing. It escaped .gitignore only because the compiler wrote it beside its source rather than into .dart_tool/ or build/. Converting would also not have shrunk the repository. A forward-only change leaves the original blobs in history, so full clones keep carrying all 21.3 MB and would additionally fetch the LFS objects on every hydrating clone -- against ~95k clones/month, purely additive bandwidth. So: exempt the four path groups, delete the build artifact, and drop the now dead *.dill LFS rule. No file is converted and no LFS object is uploaded. Adds .github/scripts/check-lfs-pointers.sh, which derives the governed set from `git check-attr` rather than any filename list, and fails in both directions -- a plain blob on a governed path, and a stranded pointer left behind by a future exemption. Verified: 34 violations on the previous main, zero here; fails on a plain blob added to a governed path and passes once the same file is re-added through LFS. Refs PNI-185 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(ci): name the LFS pointer check job Clears zizmor/anonymous-definition (note severity) on the job added by the previous commit. Only the new job is named. The two pre-existing jobs in this file trip the same rule, but renaming them would change their reported check context, and `main` is protected with required checks that are not readable without admin access -- if either is required by name, the rename would leave the context permanently pending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: document chat platforms as an AG-UI client surface The Clients listings covered web, terminal, and React Native, which left the impression that AG-UI targets web applications. Add chat platforms (Slack, Microsoft Teams) alongside the existing entries, and state that a client need not be a web app because the protocol describes an event stream rather than a rendering target. The added text separates the layers: a chat-platform client is something anyone can build against AG-UI, the Channels SDK is one implementation, OpenTag is a readable application built on it, and CopilotKit Intelligence is named as a hosted service outside the protocol. No protocol, specification, or event-set changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): make TOOL_CALL_START idempotent The TOOL_CALL_START handler appended to the parent assistant message's `toolCalls` with no check for an existing entry, so any re-application of a start event corrupted message state. The same start can reach the reducer twice — a tool call already carried in `agent.messages` from an earlier run and then replayed by the backend (the HITL path does this when the run re-syncs after `respond()`), or one stream re-delivered over two transports. The duplicate's `arguments` stay empty, because TOOL_CALL_ARGS deltas resolve to the first match. That malformed assistant message — the same tool call twice, one copy with no arguments — is what travels back to the provider on the next turn. Resolve any existing entry for the id and update it in place instead of pushing a second one, leaving `arguments` untouched (a start event carries none, so the copy already in state holds the only streamed args). A start that reuses an id under a different name warns and updates the name. The check runs before resolveOrCreateAssistantMessage, which also closes a second latent defect on the same path: a replay whose parentMessageId is no longer in state would otherwise create a stray empty assistant message to hang the duplicate off. A replay is now a true no-op — no duplicate, no stray message, and no state emission at all. Note that TOOL_CALL_ARGS remains non-idempotent by nature (deltas append). The evidence in CPK-7770 shows only the start event was replayed, so this covers the observed defect; whole-stream re-delivery would surface next as doubled arguments, which cannot be deduped without sequence numbers. Refs CPK-7772, follow-up to CPK-7770 / CopilotKit#6407 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): update swatinem/rust-cache action to v2.9.2 --------- Co-authored-by: Mark Fogle <mark@copilotkit.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Markus Ecker <markus.ecker@gmail.com> Co-authored-by: Ben Taylor <ben@copilotkit.ai>
Fixes #2205
Summary
Bridges native Strands
tool_context.interrupt()pauses to the AG-UI interrupt lifecycle, so human-in-the-loop flows work over the protocol. Previously a native interrupt either surfaced asRUN_ERRORor silently finished as a completed run, and a client'sresumepayload was dropped.This is a wrapper-only change in
integrations/aws-strands/python/— the AG-UI protocol types it relies on already ship inag-ui-protocol0.1.19.I filed #2205 ~2 weeks ago and pinged
#-💎-contributingon Discord; opening this PR to move the discussion forward. Happy to adjust scope or approach.Changes
RunAgentInput.resume— builds the Strands resume prompt[{"interruptResponse": {"interruptId", "response"}}]and drivesstream_asyncwith it (takes precedence over other stream paths, since a resume run carries no fresh prompt).status="cancelled"resumes with the documented denial sentinelINTERRUPT_CANCELLED = {"cancelled": True}.AgentResult(stop_reason == "interrupt"), captured before thecomplete/force_stopearly-break so it's never dropped; falls back to the agent's_interrupt_state.activatedif the result event was consumed.RunFinishedInterruptOutcome— one AG-UIInterruptper Strands interrupt. The Strands interrupt name maps to the categorical AG-UIreason; the free-form Strands reason object is preserved undermetadata.strands_reason. Non-interrupt runs still finish bare (no behavior change).ag-ui-protocolfloor to>=0.1.19(ships the interrupt types) and re-lock.SessionManagerrequirement for stateless / multi-container deployments (the in-memory per-thread cache only preserves interrupt state within one process).Test plan
tests/test_interrupt.py(6 cases): pause → interrupt outcome, state-fallback detection, no-interrupt-finishes-bare, resolved-resume prompt shape, cancelled sentinel, multi-entry resume.uv run pytest tests/→ 182 passed, 2 skipped.human_in_the_loopexample not included in this PR — happy to add if maintainers want it.🤖 Generated with Claude Code